先说结论,在 Vue 中,单向数据流和双向数据绑定并不冲突,因为这两个东西所处的场景不同。

双向数据绑定

单/双向数据绑定,指的是视图层和数据层之间的映射关系

Vue 在数据操作上,同时支持单向数据绑定和双向数据绑定:

  • 单向绑定:例如 Mustache 插值语法,v-bind 等,数据改变,视图也跟着改变;
  • 双向绑定:即表单的 v-model。它实际上是一个语法糖,背后包括两步操作:
    • v-bind:value:数据改变,视图跟着改变
    • v-on:input:视图改变,数据跟着改变

实现双向数据绑定的核心思路是什么?

双向数据绑定是通过数据劫持+发布-订阅模式实现的。有几个核心的模块,分别是 Watcher(数据拦截)、Observer(订阅者)、Subject(发布者)、Queue(消息队列)。

假设现在要针对 obj 对象实现双向数据绑定,首先会把 obj 传给 Watcher,Watcher 内部会遍历 obj 的每一个 key,为其添加 setter 和 getter,最后把加工好的 obj 返回。接着遍历 obj,产生一个 Queue,key 仍然是 obj 的 key,value 则是一个 Subject 发布者。Subject 自身维护一个订阅者数组,通过 attach 方法往数组中添加订阅者,通过 notify 发布事件,将订阅者对应的回调函数一一执行。Observer 订阅者所在的事情,就是调用 Subject 的 attach 方法添加一个回调函数。

假设 obj 对象的 a 属性改变了,那么就会触发 setter 函数,setter 函数里面会调用 Queue[a] 这个发布者的 notify 方法,做一个事件的发布,而 notify 方法会把发布者自身维护的数组里面的回调函数取出来一一执行。那么,这里的回调函数会做什么事情呢?其实我们可以把订阅者看作是所有依赖 a 属性的 dom 元素,回调函数做的事情就是根据目前最新的 a 属性做一个视图的更新。

单向数据流

单向数据流,指的是组件之间的数据流动是单向的

单向数据流的表现

假设子组件接受了父组件的 prop ,想要对其做一个双向绑定,那么我们的代码可能会这么写:

<div id="app">
  <cpn v-bind:value2="value"></cpn>
</div>
<template id="cpn">
    <input type="text" v-model="value2">
    <h2>{{value2}}</h2>    
</template>
const cpn = {
  template:"#cpn",
  props:["value2"]
}
const app  = new Vue({
  el:'#app',
  data:{
    value:0
  },
  components:{
    cpn
  }
})

我们会发现 model 层的确随着 view 层同步改变了,但是控制台里会报错:

这是因为,prop 是父组件传过来的原始数据,而我们现在试图通过子组件的 v-model 去改变这个 prop,也就是试图通过子组件直接去改变父组件的数据(而不是通过发送事件的方式),这是不允许的,因为 Vue 是单向数据流 —— 也就是说,数据总是从父组件传到子组件,子组件没有权利修改父组件传过来的数据,只能请求父组件对原始数据进行修改。

为什么是单向数据流

在设计上为什么是单向数据流呢?

  • 首先第一个是方便追踪状态变更。如果是双向数据流,则任意一个组件都可以修改父组件的 prop,那么状态变更的追踪将会很困难
  • 第二个是实现组件状态解耦。如果是双向数据流,则任意一个组件都可以修改父组件的 prop,这会影响到那些同样接受了父组件 prop 的子组件
  • 第三个是从函数式编程的角度理解。实际上组件可以视为一个函数,那么 prop 就是它接受的参数,如果说函数可以修改到这个参数,那么这个函数就是有副作用的,不是纯函数。而组件在设计上应该是一个“纯函数”,这意味着它无法改变入参的值。

单向数据流下如何修改父组件的数据

但是,很多时候我们又确实要操作这个数据,那么应该怎么办呢?
有两种方法:

  • 定义一个局部变量,并用 prop 的值初始化它:
props: ['initialCounter'],
data: function () {
  return {
    counter: this.initialCounter
  }
}
  • 定义一个计算属性,处理 prop 的值并返回:
props: ['size'],
computed: {
  normalizedSize: function () {
    return this.size.trim().toLowerCase()
  }
}

第一个方法相当于创建了原始 prop 的副本,之后怎么操作数据都是操作的子组件数据,不会影响到父组件数据;第二个方法,注意 trim() 会返回一个处理完成后的新字符串,同样不会影响到父组件数据(原字符串)。之后如果父组件确实要用到这个处理后的值,就通过 $emit 的方式传给父组件即可。

拿前面的例子来说,我们想要利用 prop 这个数据实现双向绑定,可以这么写:

<div id="app">
  <cpn v-bind:value2="value"></cpn>
</div>
<template id="cpn">
    <input type="text" v-model="value3">
    <h2>{{value3}}</h2>    
</template>
const cpn = {
  template:"#cpn",
  props:["value2"],
  data:{
    value3:this.value2
  }
}
const app  = new Vue({
  el:'#app',
  data:{
    value:0
  },
  components:{
    cpn
  }
})

这样子就不会报错了,因为现在我们操作的是子组件自己的数据,和 prop 无关。

还要注意一个问题:

注意在 JavaScript 中对象和数组是通过引用传入的,所以对于一个数组或对象类型的 prop 来说,在子组件中改变这个对象或数组本身将会影响到父组件的状态。

比如下面这段代码:

<div id="app">
  <h2>父组件数据:{{parent}}</h2>
  <cpn v-bind:obj1="parent"></cpn>
</div>
<template id="cpn">
  <div>
    <h2>子组件数据:{{son}}</h2>
    <input type="text" v-model="son.age">
  </div>
</template>
const cpn = {
  template:"#cpn",
  props:["obj1"],
  data(){
    return {
      son:this.obj1
    }
  }
}
const app  = new Vue({
  el:'#app',
  data:{
    parent:{age:20}
  },
  components:{
    cpn
  }
})

这里的 this.obj1 是引用,赋值给了 son,所以 son 实际上还是指向了父组件的数据,对 son.age 的修改依然会影响到父组件,如图:

所以,我们实际上需要的是一个对象副本。因为对象属性都是基本类型,这里只用浅拷贝即可(如果对象属性还是对象,就得用深拷贝):

const cpn = {
  template:"#cpn",
  props:["obj1"],
  data(){
    return {
      // son:this.obj1
      son:Object.assign({},this.obj1)
    }
  }
}

之后会发现,子组件的数据操作不再影响到父组件:

如何理解 Vuex 的单向数据流

参考:
https://cn.vuejs.org/v2/guide/components-props.html
https://juejin.im/entry/59e8b8a8518825579d131e51